Packages

library(tidyverse)
## ── Attaching packages ─────────────────────────────────────── tidyverse 1.3.1 ──
## ✔ ggplot2 3.3.6     ✔ purrr   0.3.4
## ✔ tibble  3.1.7     ✔ dplyr   1.0.9
## ✔ tidyr   1.2.0     ✔ stringr 1.4.0
## ✔ readr   2.1.2     ✔ forcats 0.5.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
library(gganimate)
library(ggtext)
library(lubridate)
## 
## Attaching package: 'lubridate'
## The following objects are masked from 'package:base':
## 
##     date, intersect, setdiff, union

Get the data

Statcounter provides mobile vendor market share back to 2010. The original video goes back to the 1990s. The data can be downloaded in CSV format via https://gs.statcounter.com/vendor-market-share/mobile/worldwide/#monthly-201003-202205 After the download, place the CSV file in your project directory.

filename <- "vendor-ww-monthly-201003-202205.csv"
df_raw <- read_csv(filename)
## Rows: 147 Columns: 70
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr  (1): Date
## dbl (69): Samsung, Apple, Unknown, Nokia, Huawei, Xiaomi, LG, Oppo, Sony, Mo...
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

Transform the data

Transform the data to long format for our next steps.

threshold_for_lumping <- 3.1

df_long <- df_raw %>%
  pivot_longer(cols = -Date, names_to = "vendor", values_to = "market_share") %>% 
  # group vendors with smaller market shares to "Other" based on monthly shares
  mutate(vendor2 = ifelse(market_share < threshold_for_lumping | 
                            vendor == "Unknown", "Other", vendor),
         date = ym(Date)) %>% 
  filter(date > as_date("2010-03-01")) %>% 
  count(date, vendor2, wt = market_share, name = "market_share")

The first few rows of the transformed dataframe:

head(df_long)
## # A tibble: 6 × 3
##   date       vendor2 market_share
##   <date>     <chr>          <dbl>
## 1 2010-04-01 Apple          33.2 
## 2 2010-04-01 Nokia          37.6 
## 3 2010-04-01 Other           5.11
## 4 2010-04-01 RIM            15.9 
## 5 2010-04-01 Sony            8.3 
## 6 2010-05-01 Apple          33.1

All grouped vendors:

unique(df_long$vendor2)
##  [1] "Apple"   "Nokia"   "Other"   "RIM"     "Sony"    "Samsung" "HTC"    
##  [8] "LG"      "Huawei"  "Lenovo"  "Xiaomi"  "Oppo"    "Mobicel" "Vivo"

First plot with facets

df_long %>% 
  filter(date <= as_date("2011-03-01")) %>% 
  ggplot(aes(vendor2, market_share)) +
  geom_col() +
  coord_flip() +
  facet_wrap(vars(date))

Create a donut chart with ggplot2

{ggpubr} is great for creating several chart types, include donut charts. However, for our animation we will build the plot from scratch in {ggplot2}.

Start with a pie chart

p <- df_long %>% 
  filter(date == as_date("2022-05-01")) %>%
  arrange(date, market_share) %>% 
  mutate(label_pos = cumsum(market_share) / sum(market_share) 
         - 0.5 * market_share /  sum(market_share),
         label = sprintf("%s<br>%s %%", vendor2, market_share),
         # label = fct_reorder(label, -market_share),
         vendor2 = fct_reorder(vendor2, -market_share)) %>% 
  ggplot(aes(x = 1, market_share, group = vendor2)) +
  geom_col(aes(fill = vendor2), position = "fill") +
  geom_richtext(aes(x = 1.5, label = label, y = label_pos)) +
  paletteer::scale_fill_paletteer_d("palettetown::dratini") +
  coord_polar(theta = "y") +
  guides(fill = "none") +
  theme_void()
p

… and the donut

We just simply add a white circle on top of the pie chart. Voilà, a donut chart. Adjust donut_hole_width to increase or decrease the donut bar. A value of 0 will result in a pie chart, a value of 1.5 or greater will cover the whole pie chart.

donut_hole_width <- 0.75

p_donut <- p + 
  annotate("rect", xmin = 0, xmax = donut_hole_width, ymin = -Inf, ymax = Inf,
           fill = "white") +
  geom_text(aes(x = 0, y = 0, label = format(date, "%B\n%Y")), stat = "unique",
            size = 8)
p_donut

First animation

p_donut <- 
  df_long %>% 
  # filter(date == as_date("2022-05-01")) %>%
  mutate(vendor2 = factor(vendor2, levels = unique(df_long$vendor2))) %>% 
  # arrange(date, vendor2) %>% 
  # now we need to calculate the label position within each month
  group_by(date) %>% 
  arrange(desc(vendor2), .by_group = TRUE) %>% 
  mutate(label_pos = cumsum(market_share) / sum(market_share)  
         - 0.5 * market_share / sum(market_share),
         label = sprintf("%s\n%s %%", vendor2, 
                         scales::number(market_share, accuracy = 0.1)),
         label = fct_reorder(label, market_share)
         # vendor2 = fct_reorder(vendor2, -market_share)
         ) %>% 
  ungroup() %>% 
  ggplot(aes(x = 1, market_share, group = vendor2)) +
  geom_col(aes(fill = vendor2), position = "fill") +
  ggrepel::geom_text_repel(aes(x = 1.5, label = label, y = label_pos),
                hjust = 0, family = "Fira Sans", segment.size = 0.3,
                min.segment.length = 0, nudge_x = 0.3, point.padding = 1e-05,
                label.padding = 0.3, color = "white") +
  # semi-transparent ring
  annotate("rect", xmin = 0, xmax = donut_hole_width + 0.15, ymin = -Inf, ymax = Inf,
           fill = alpha("grey4", 0.25)) +
  # inner ring
  annotate("rect", xmin = 0, xmax = donut_hole_width, ymin = -Inf, ymax = Inf,
           fill = "grey4") +
  geom_richtext(
    aes(x = 0, y = 0,
        label = sprintf(
          "<span style='color: grey80'>%s</span><br><span style='font-size: 40pt'>%s</span>", 
          format(date, "%B"), year(date))), 
                stat = "unique", size = 8, family = "Fira Sans SemiBold", color = "white",
                fill = NA, label.size = 0, lineheight = 1.67) +
  paletteer::scale_fill_paletteer_d("palettetown::lapras") +
  coord_polar(theta = "y") +
  guides(fill = "none", color = "none") +
  labs(
    title = "Mobile phone market 2010-2022",
    subtitle = "Market share of mobile phone vendors"
  ) + 
  theme_void(base_family = "Fira Sans") + 
  theme(
    plot.background = element_rect(color = NA, fill = "grey4"),
    plot.margin = margin(10, 10, 10, 10),
    text = element_text(color = "grey80"),
    plot.title = element_text(
      family = "Fira Sans SemiBold", color = "white", size = 16),
    plot.title.position = "plot"
  )
## Warning: Ignoring unknown parameters: label.padding
p_anim <- p_donut +
  transition_states(date)

anim <- animate(p_anim, res = 100, width = 640, height = 600, fps = 12,
                duration = 60)
anim_save("animated-donut-chart.gif", anim)